| 1 | import * as Clipboard from "expo-clipboard"; |
| 2 | import { Stack, router, useLocalSearchParams } from "expo-router"; |
| 3 | import { useHeaderHeight } from "expo-router/react-navigation"; |
| 4 | import * as WebBrowser from "expo-web-browser"; |
| 5 | import { |
| 6 | CircleCheck, |
| 7 | CircleDot, |
| 8 | CircleX, |
| 9 | Copy, |
| 10 | ExternalLink, |
| 11 | FolderGit2, |
| 12 | Link2, |
| 13 | Plus, |
| 14 | Search, |
| 15 | Unlink, |
| 16 | } from "lucide-react-native"; |
| 17 | import { useState } from "react"; |
| 18 | import { Alert, KeyboardAvoidingView, RefreshControl, ScrollView, StyleSheet, TextInput, View, useWindowDimensions } from "react-native"; |
| 19 | |
| 20 | import { Sheet, SheetAction } from "@/components/chat/sheet"; |
| 21 | import { Body, Checks, PullIcon, Timeline } from "@/components/code"; |
| 22 | import { Badge, Button, ErrorView, Loading, Row, Screen, SectionHeader, Text } from "@/components/ui"; |
| 23 | import { Chip, CommentBar, ItemIcon, repoName, roleLabel, stateLabel } from "@/components/work"; |
| 24 | import { useRepos } from "@/lib/code"; |
| 25 | import { siteUrl } from "@/lib/server"; |
| 26 | import { useSignedIn } from "@/lib/session"; |
| 27 | import { ago } from "@/lib/time"; |
| 28 | import { type ApiItemRepo, type ApiWorkPull, type ApiWorkRepo, useItemActions, useWorkContext, useWorkItem } from "@/lib/work"; |
| 29 | import { radius, space, useColors } from "@/theme"; |
| 30 | |
| 31 | /** |
| 32 | * A work item: what it asks for, the repositories it is linked to (where |
| 33 | * it came from, where it is meant to land, and what has landed where), |
| 34 | * each one's pull requests for it, and its conversation. The bar at the |
| 35 | * foot comments, and its ⋯ closes, reopens and links a repository. |
| 36 | */ |
| 37 | export default function WorkItem() { |
| 38 | const params = useLocalSearchParams<{ number: string; workspace?: string }>(); |
| 39 | const { server, workspace: current } = useSignedIn(); |
| 40 | const workspace = params.workspace ?? current?.slug ?? ""; |
| 41 | const number = Number(params.number); |
| 42 | const detail = useWorkItem(workspace, number); |
| 43 | const context = useWorkContext(workspace, number); |
| 44 | const actions = useItemActions(workspace, number); |
| 45 | const headerHeight = useHeaderHeight(); |
| 46 | const colors = useColors(); |
| 47 | const [more, setMore] = useState(false); |
| 48 | const [linking, setLinking] = useState(false); |
| 49 | const [picked, setPicked] = useState<ApiItemRepo | null>(null); |
| 50 | |
| 51 | const title = <Stack.Screen options={{ title: `#${number}` }} />; |
| 52 | if (detail.isPending || detail.error) { |
| 53 | return ( |
| 54 | <Screen> |
| 55 | {title} |
| 56 | {detail.error ? <ErrorView error={detail.error} retry={() => detail.refetch()} /> : <Loading />} |
| 57 | </Screen> |
| 58 | ); |
| 59 | } |
| 60 | |
| 61 | const { item, comments } = detail.data; |
| 62 | const by = item.author.display_username ?? item.author.username; |
| 63 | const refs = { workspace }; |
| 64 | const sitePath = `/${workspace}/-/work/${number}`; |
| 65 | // The context has each repository's pull requests; the item alone still has its repositories. |
| 66 | const repos: ApiWorkRepo[] = context.data?.repos ?? item.repos.map((repo) => ({ ...repo, pulls: [] })); |
| 67 | const missing = context.data?.missing ?? item.repos.filter((repo) => repo.role === "target" && !repo.landed).map((repo) => repo.path); |
| 68 | const linked = new Set(repos.map((repo) => repo.path.toLowerCase())); |
| 69 | const suggested = (context.data?.suggested_targets ?? []).filter((one) => !linked.has(one.path.toLowerCase())); |
| 70 | |
| 71 | const failed = (what: string) => (error: unknown) => Alert.alert(what, error instanceof Error ? error.message : "Something went wrong."); |
| 72 | const close = (reason: "completed" | "not_planned") => { |
| 73 | setMore(false); |
| 74 | actions.close.mutate(reason, { onError: failed("Could not close it") }); |
| 75 | }; |
| 76 | const reopen = () => { |
| 77 | setMore(false); |
| 78 | actions.reopen.mutate(undefined, { onError: failed("Could not reopen it") }); |
| 79 | }; |
| 80 | const link = (repo: string) => { |
| 81 | setLinking(false); |
| 82 | actions.link.mutate({ repo, role: "target" }, { onError: failed("Could not link it") }); |
| 83 | }; |
| 84 | const openRepo = (path: string) => { |
| 85 | const [owner, name] = path.split("/", 2); |
| 86 | router.push({ pathname: "/code/[owner]/[repo]", params: { owner, repo: name } }); |
| 87 | }; |
| 88 | const openPull = (pull: ApiWorkPull) => { |
| 89 | const [owner, name] = pull.repo.split("/", 2); |
| 90 | router.push({ pathname: "/code/[owner]/[repo]/pull/[number]", params: { owner, repo: name, number: String(pull.number) } }); |
| 91 | }; |
| 92 | const busy = actions.close.isPending || actions.reopen.isPending; |
| 93 | |
| 94 | return ( |
| 95 | <Screen> |
| 96 | {title} |
| 97 | <KeyboardAvoidingView style={{ flex: 1 }} behavior="padding" keyboardVerticalOffset={headerHeight}> |
| 98 | <ScrollView |
| 99 | contentContainerStyle={styles.content} |
| 100 | keyboardShouldPersistTaps="handled" |
| 101 | keyboardDismissMode="interactive" |
| 102 | refreshControl={ |
| 103 | <RefreshControl |
| 104 | refreshing={detail.isRefetching} |
| 105 | onRefresh={() => Promise.all([detail.refetch(), context.refetch()])} |
| 106 | tintColor={colors.accent} |
| 107 | /> |
| 108 | } |
| 109 | > |
| 110 | <View style={{ gap: space.sm }}> |
| 111 | <Text size="heading" selectable> |
| 112 | {item.title} |
| 113 | </Text> |
| 114 | <View style={styles.wrap}> |
| 115 | <ItemIcon item={item} size={18} /> |
| 116 | <Text size="small" style={{ fontWeight: "600", color: item.state === "open" ? colors.success : colors.muted }}> |
| 117 | {stateLabel(item)} |
| 118 | </Text> |
| 119 | <Text size="small" tone="muted"> |
| 120 | #{item.number} · {by} · {ago(item.created_at)} |
| 121 | </Text> |
| 122 | </View> |
| 123 | {item.labels.length || item.milestone || item.assignees.length ? ( |
| 124 | <View style={styles.wrap}> |
| 125 | {item.labels.map((label) => ( |
| 126 | <Chip key={label} label={label} /> |
| 127 | ))} |
| 128 | {item.milestone ? <Chip label={item.milestone.title} /> : null} |
| 129 | {item.assignees.map((name) => ( |
| 130 | <Chip key={name} label={`@${name}`} /> |
| 131 | ))} |
| 132 | </View> |
| 133 | ) : null} |
| 134 | </View> |
| 135 | |
| 136 | <Body text={item.body} refs={refs} /> |
| 137 | |
| 138 | <View style={{ marginHorizontal: -space.lg }}> |
| 139 | <SectionHeader title="Repositories" /> |
| 140 | {repos.length === 0 ? ( |
| 141 | <Text tone="muted" style={{ paddingHorizontal: space.lg }}> |
| 142 | Not linked to a repository yet. Link the ones where the work is meant to land. |
| 143 | </Text> |
| 144 | ) : ( |
| 145 | repos.map((repo) => ( |
| 146 | <View key={repo.repo_id}> |
| 147 | <Row |
| 148 | leading={<FolderGit2 size={20} color={colors.muted} />} |
| 149 | title={repoName(repo.path, workspace)} |
| 150 | subtitle={`${roleLabel(repo.role)}${repo.legacy_number ? ` · was #${repo.legacy_number} there` : ""}`} |
| 151 | trailing={ |
| 152 | repo.landed ? ( |
| 153 | <Badge label="Landed" tone="merged" /> |
| 154 | ) : repo.role === "target" ? ( |
| 155 | <Badge label="Not landed" tone="warn" /> |
| 156 | ) : null |
| 157 | } |
| 158 | // A deleted repository has nothing to open or unlink. |
| 159 | onPress={repo.path ? () => setPicked(repo) : undefined} |
| 160 | /> |
| 161 | {repo.pulls.map((pull) => ( |
| 162 | <View key={`${pull.repo}#${pull.number}`} style={{ paddingLeft: space.xl }}> |
| 163 | <Row |
| 164 | leading={<PullIcon pull={pull} />} |
| 165 | title={pull.title} |
| 166 | subtitle={`#${pull.number} · ${pull.status}`} |
| 167 | trailing={<Checks status={pull.check_status} />} |
| 168 | onPress={() => openPull(pull)} |
| 169 | /> |
| 170 | </View> |
| 171 | ))} |
| 172 | </View> |
| 173 | )) |
| 174 | )} |
| 175 | {missing.length && item.state === "open" ? ( |
| 176 | <Text size="small" tone="muted" style={{ paddingHorizontal: space.lg, paddingTop: space.sm }}> |
| 177 | Closes once it lands in {missing.map((path) => repoName(path, workspace)).join(", ")}. |
| 178 | </Text> |
| 179 | ) : null} |
| 180 | <View style={{ paddingHorizontal: space.lg, paddingTop: space.md }}> |
| 181 | <Button title="Link a repository" variant="secondary" icon={Link2} onPress={() => setLinking(true)} /> |
| 182 | </View> |
| 183 | </View> |
| 184 | |
| 185 | {suggested.length ? ( |
| 186 | <View style={{ marginHorizontal: -space.lg }}> |
| 187 | <SectionHeader title="Suggested" /> |
| 188 | {suggested.map((one) => ( |
| 189 | <Row |
| 190 | key={one.repo_id} |
| 191 | leading={<FolderGit2 size={20} color={colors.faint} />} |
| 192 | title={repoName(one.path, workspace)} |
| 193 | subtitle={one.why} |
| 194 | chevron={false} |
| 195 | trailing={ |
| 196 | <Button |
| 197 | title="Add as target" |
| 198 | variant="secondary" |
| 199 | icon={Plus} |
| 200 | busy={actions.link.isPending && actions.link.variables?.repo === one.path} |
| 201 | onPress={() => link(one.path)} |
| 202 | /> |
| 203 | } |
| 204 | /> |
| 205 | ))} |
| 206 | </View> |
| 207 | ) : null} |
| 208 | |
| 209 | <Timeline comments={comments} refs={refs} /> |
| 210 | |
| 211 | {item.state === "open" ? ( |
| 212 | <Button title="Close" variant="secondary" icon={CircleCheck} busy={busy} onPress={() => setMore(true)} /> |
| 213 | ) : ( |
| 214 | <Button title="Reopen" variant="secondary" icon={CircleDot} busy={busy} onPress={reopen} /> |
| 215 | )} |
| 216 | </ScrollView> |
| 217 | <CommentBar |
| 218 | busy={actions.comment.isPending} |
| 219 | onMore={() => setMore(true)} |
| 220 | onSend={(body) => actions.comment.mutateAsync(body).catch((error: unknown) => (failed("Could not comment")(error), Promise.reject(error)))} |
| 221 | /> |
| 222 | </KeyboardAvoidingView> |
| 223 | |
| 224 | <Sheet visible={more} onClose={() => setMore(false)}> |
| 225 | {item.state === "open" ? ( |
| 226 | <> |
| 227 | <SheetAction icon={CircleCheck} label="Close as done" onPress={() => close("completed")} /> |
| 228 | <SheetAction icon={CircleX} label="Close as not planned" onPress={() => close("not_planned")} /> |
| 229 | </> |
| 230 | ) : ( |
| 231 | <SheetAction icon={CircleDot} label="Reopen" onPress={reopen} /> |
| 232 | )} |
| 233 | <SheetAction |
| 234 | icon={Link2} |
| 235 | label="Link a repository" |
| 236 | onPress={() => { |
| 237 | setMore(false); |
| 238 | setLinking(true); |
| 239 | }} |
| 240 | /> |
| 241 | <SheetAction |
| 242 | icon={Copy} |
| 243 | label="Copy link" |
| 244 | onPress={() => { |
| 245 | setMore(false); |
| 246 | void Clipboard.setStringAsync(siteUrl(server, sitePath)); |
| 247 | }} |
| 248 | /> |
| 249 | <SheetAction |
| 250 | icon={ExternalLink} |
| 251 | label="Open on g1t" |
| 252 | onPress={() => { |
| 253 | setMore(false); |
| 254 | void WebBrowser.openBrowserAsync(siteUrl(server, sitePath)); |
| 255 | }} |
| 256 | /> |
| 257 | </Sheet> |
| 258 | |
| 259 | <Sheet visible={picked !== null} onClose={() => setPicked(null)}> |
| 260 | {picked ? ( |
| 261 | <> |
| 262 | <Text size="heading" style={{ paddingHorizontal: space.lg }}> |
| 263 | {picked.path} |
| 264 | </Text> |
| 265 | <SheetAction |
| 266 | icon={FolderGit2} |
| 267 | label="Open the repository" |
| 268 | onPress={() => { |
| 269 | setPicked(null); |
| 270 | openRepo(picked.path); |
| 271 | }} |
| 272 | /> |
| 273 | {picked.role !== "origin" ? ( |
| 274 | <SheetAction |
| 275 | icon={Unlink} |
| 276 | label="Unlink it" |
| 277 | danger |
| 278 | onPress={() => { |
| 279 | setPicked(null); |
| 280 | actions.unlink.mutate(picked.path, { onError: failed("Could not unlink it") }); |
| 281 | }} |
| 282 | /> |
| 283 | ) : null} |
| 284 | </> |
| 285 | ) : null} |
| 286 | </Sheet> |
| 287 | |
| 288 | <LinkSheet visible={linking} linked={linked} onClose={() => setLinking(false)} onPick={link} /> |
| 289 | </Screen> |
| 290 | ); |
| 291 | } |
| 292 | |
| 293 | /** Picks a repository of the workspace to link as a target. */ |
| 294 | function LinkSheet({ visible, linked, onClose, onPick }: { visible: boolean; linked: Set<string>; onClose: () => void; onPick: (path: string) => void }) { |
| 295 | const colors = useColors(); |
| 296 | const { height } = useWindowDimensions(); |
| 297 | const [search, setSearch] = useState(""); |
| 298 | const repos = useRepos(visible ? search.trim() : ""); |
| 299 | const choices = (repos.data ?? []).filter((repo) => !linked.has(`${repo.namespace}/${repo.name}`.toLowerCase())); |
| 300 | return ( |
| 301 | <Sheet visible={visible} onClose={onClose}> |
| 302 | <View style={{ paddingHorizontal: space.lg, gap: space.sm }}> |
| 303 | <Text size="heading">Link a repository</Text> |
| 304 | <Text size="small" tone="muted"> |
| 305 | As a target: where the work is meant to land. It closes once it has landed in every target. |
| 306 | </Text> |
| 307 | <View style={[styles.search, { borderColor: colors.lineStrong, backgroundColor: colors.bg }]}> |
| 308 | <Search size={16} color={colors.faint} /> |
| 309 | <TextInput |
| 310 | value={search} |
| 311 | onChangeText={setSearch} |
| 312 | placeholder="Find a repository" |
| 313 | placeholderTextColor={colors.faint} |
| 314 | autoCapitalize="none" |
| 315 | autoCorrect={false} |
| 316 | style={{ flex: 1, color: colors.fg, fontSize: 15, paddingVertical: space.sm }} |
| 317 | /> |
| 318 | </View> |
| 319 | </View> |
| 320 | <ScrollView style={{ maxHeight: height * 0.5 }} keyboardShouldPersistTaps="handled"> |
| 321 | {repos.isPending ? ( |
| 322 | <Loading /> |
| 323 | ) : choices.length === 0 ? ( |
| 324 | <Text tone="muted" style={{ padding: space.lg }}> |
| 325 | {search ? "Nothing matches." : "Every repository you can see is linked already."} |
| 326 | </Text> |
| 327 | ) : ( |
| 328 | choices.map((repo) => ( |
| 329 | <Row |
| 330 | key={repo.id} |
| 331 | leading={<FolderGit2 size={20} color={colors.muted} />} |
| 332 | title={repo.name} |
| 333 | subtitle={`${repo.namespace}/${repo.name}`} |
| 334 | chevron={false} |
| 335 | trailing={<Plus size={18} color={colors.accent} />} |
| 336 | onPress={() => onPick(`${repo.namespace}/${repo.name}`)} |
| 337 | /> |
| 338 | )) |
| 339 | )} |
| 340 | </ScrollView> |
| 341 | </Sheet> |
| 342 | ); |
| 343 | } |
| 344 | |
| 345 | const styles = StyleSheet.create({ |
| 346 | content: { padding: space.lg, gap: space.lg, paddingBottom: space.xl }, |
| 347 | wrap: { flexDirection: "row", flexWrap: "wrap", alignItems: "center", gap: 6 }, |
| 348 | search: { flexDirection: "row", alignItems: "center", gap: space.sm, borderWidth: 1, borderRadius: radius.md, paddingHorizontal: space.md }, |
| 349 | }); |